/*
    Licensed to the Apache Software Foundation (ASF) under one
    or more contributor license agreements.  See the NOTICE file
    distributed with this work for additional information
    regarding copyright ownership.  The ASF licenses this file
    to you under the Apache License, Version 2.0 (the
    "License"); you may not use this file except in compliance
    with the License.  You may obtain a copy of the License at

       http://www.apache.org/licenses/LICENSE-2.0

    Unless required by applicable law or agreed to in writing,
    software distributed under the License is distributed on an
    "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
    KIND, either express or implied.  See the License for the
    specific language governing permissions and limitations
    under the License.
 */
package erwiki.plugins.pageview;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.text.MessageFormat;
import java.util.Comparator;
import java.util.Dictionary;
import java.util.Hashtable;
import java.util.Iterator;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Properties;
import java.util.TreeMap;

import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.math.NumberUtils;
import org.apache.log4j.Logger;
import org.apache.oro.text.GlobCompiler;
import org.apache.oro.text.regex.MalformedPatternException;
import org.apache.oro.text.regex.Pattern;
import org.apache.oro.text.regex.PatternCompiler;
import org.apache.oro.text.regex.PatternMatcher;
import org.apache.oro.text.regex.Perl5Matcher;
import org.elwiki_data.WikiPage;
import org.osgi.framework.BundleContext;
import org.osgi.framework.FrameworkUtil;
import org.osgi.framework.ServiceRegistration;
import org.osgi.service.event.Event;
import org.osgi.service.event.EventConstants;
import org.osgi.service.event.EventHandler;

import erwiki.api.BackgroundThreads;
import erwiki.api.BackgroundThreads.Actor;
import erwiki.api.WikiPartReference;
import erwiki.api.core.Engine;
import erwiki.api.core.WikiContext;
import erwiki.api.event.EngineEvent;
import erwiki.api.event.PageEvent;
import erwiki.api.exceptions.PluginException;
import erwiki.api.pages.Id2NamePage;
import erwiki.api.plugin.InitializablePlugin;
import erwiki.api.plugin.PluginManager;
import erwiki.api.plugin.WikiPlugin;
import erwiki.plugins.AbstractReferralPlugin;

/**
 * Плагин ErWiki. Этот плагин подсчитывает количество просмотров страницы.</br>
 * Плагин адресует страницы по их идентификаторам.
 * <p>
 * Параметры плагина:
 * <ul>
 * <li><b>count</b> - Производить ли подсчет просмотров этой страницы: <i>yes</i>|no.</li>
 * <li><b>show</b> - Формат вывода, значения: <i>none</i>|count|list.</li>
 * <li><b>entries</b> - Максимальное количество возвращаемых записей списка.</li>
 * <li><b>min</b> - Порог минимального числа просмотров страниц, для размещения в списке.</li>
 * <li><b>max</b> - Порог максимального числа просмотров страниц, для размещения в списке.</li>
 * <li><b>sort</b> - Метод сортировки: <i>name</i>|count</li>
 * </ul>
 * Default values:</br>
 * <code>show=none  sort=name</code>
 */
public class PageViewPlugin extends AbstractReferralPlugin implements WikiPlugin, InitializablePlugin {

	private static final Logger log = Logger.getLogger(PageViewPlugin.class);

	/** Менеджер просмотра страниц. */
	private static PageViewManager c_singleton = null;

	/** Имя параметра для настройки <i>count</i>. Значение <tt>{@value}</tt>. */
	private static final String PARAM_COUNT = "count";

	/** Имя параметра для настройки <i>entries</i>. Значение <tt>{@value}</tt>. */
	private static final String PARAM_MAX_ENTRIES = "entries";

	/** Имя параметра для настройки <i>max</i>. Значение <tt>{@value}</tt>. */
	private static final String PARAM_MAX_COUNT = "max";

	/** Имя параметра для настройки <i>min</i>. Значение <tt>{@value}</tt>. */
	private static final String PARAM_MIN_COUNT = "min";

	/** Имя параметра для настройки <i>sort</i>. Значение <tt>{@value}</tt>. */
	private static final String PARAM_SORT = "sort";

	/** Constant for the 'none' parameter value. */
	private static final String STR_NONE = "none";

	/** Constant for the 'count' parameter value. */
	private static final String STR_COUNT = "count";

	/** Constant for the 'list' parameter value. */
	private static final String STR_LIST = "list";

	/** Constant for the 'yes' parameter value. */
	private static final String STR_YES = "yes";

	/** Constant for Wiki markup separator. */
	private static final String STR_SEPARATOR = "----";

	/** Constant for comma-separated list separator. */
	private static final String STR_COMMA = ",";

	/** Constant for no-op glob expression. */
	private static final String STR_GLOBSTAR = "*";

	/** Constant for file storage. */
	private static final String COUNTER_PAGE = "PageCount.txt";

	/** Constant for storage interval in seconds. */
	private static final int STORAGE_INTERVAL = 60;

	@WikiPartReference
	Engine engine;

	@WikiPartReference
	private Id2NamePage id2NamePage;

	@WikiPartReference
	BackgroundThreads backgroundThreads;

	/**
	 * Initialize the PageViewPlugin and its singleton.
	 */
	@Override
	public void initialize() {
		log.info("initializing PageViewPlugin");
		synchronized (this) {
			if (c_singleton == null) {
				c_singleton = new PageViewManager();
			}
			c_singleton.initialize();
		}
	}

	/**
	 * Очищает ссылку на синглтон.
	 */
	private void cleanup() {
		log.info("cleaning up PageView Manager");
		c_singleton = null;
	}

	/**
	 * {@inheritDoc}
	 */
	@Override
	public String execute(WikiContext context, Map<String, String> params) throws PluginException {
		super.initialize(context, params);

		PageViewManager manager = c_singleton;
		String result = "";

		if (manager != null) {
			result = manager.execute(context, params);
		}

		return result;
	}

	/**
	 * Менеджер просмотра страниц, управляющий хранилищем плагина.
	 */
	public class PageViewManager implements EventHandler {
		/** Мы инициализированы? */
		private boolean m_initialized = false;

		/** Счетчики страниц. <pageId, Counter> */
		private Map<String, Counter> m_counters = null;

		/** Счетчики страниц в формате хранения. */
		private Properties m_storage = null;

		/** Требуется ли сохранить все изменения? */
		private boolean m_dirty = false;

		/** Актор хранилища количества страниц фонового потока. */
		private CounterSaveActor m_pageCounterSaveActor = null;

		/** Рабочий каталог. */
		private String m_workDir = null;

		/** Компаратор для сортировки количества страниц по убыванию. */
		private Comparator<Object> m_compareCountDescending = (o1, o2) -> {
			int v1 = getCount(o1);
			int v2 = getCount(o2);
			return (v1 == v2) ? ((String) o1).compareTo((String) o2) : (v1 < v2) ? 1 : -1;
		};

		private ServiceRegistration<?> serviceRegistration;

		/**
		 * Инициализирует менеджер просмотра страниц.
		 *
		 * @param engine - wiki Engine.
		 */
		public synchronized void initialize() {
			log.info("initializing PageView Manager");
			m_workDir = engine.getWorkDir().toString();

			if (m_counters == null) {
				// Загрузка счетчиков в коллекцию.
				m_storage = new Properties();
				m_counters = new TreeMap<>();

				loadCounters();
			}

			// Сохранять счетчики каждые 5 минут.
			if (m_pageCounterSaveActor == null) {
				m_pageCounterSaveActor = new CounterSaveActor(this);
				Thread pageCountSaveThread = backgroundThreads.createThread("PageViewPluginActor", 5 * STORAGE_INTERVAL,
						m_pageCounterSaveActor);
				pageCountSaveThread.start();
			}

			/* Зарегистрироваться  для прослушивания событий из EventAdmin. */
			String[] topics = new String[] { //
					EngineEvent.Topic.SHUTDOWN, //
					PageEvent.Topic.DELETED, //
			};
			Dictionary<String, Object> properties = new Hashtable<String, Object>();
			properties.put(EventConstants.EVENT_TOPIC, topics);
			BundleContext bundleContext = FrameworkUtil.getBundle(this.getClass()).getBundleContext();
			serviceRegistration = bundleContext.registerService(EventHandler.class.getName(), this, properties);

			m_initialized = true;
		}

		/**
		 * Обработка события завершения работы Engine.
		 */
		private synchronized void handleShutdown() {
			/* Дизрегистрироваться в EventAdmin. */
			BundleContext bundleContext = FrameworkUtil.getBundle(this.getClass()).getBundleContext();
			bundleContext.ungetService(serviceRegistration.getReference());

			log.info("handleShutdown: The counter store thread was shut down.");

			cleanup();

			if (m_counters != null) {

				m_dirty = true;
				storeCounters();

				m_counters.clear();
				m_counters = null;

				m_storage.clear();
				m_storage = null;
			}

			m_initialized = false;

			m_pageCounterSaveActor = null;
		}

		@Override
		public void handleEvent(Event event) {
			String topic = event.getTopic();
			switch (topic) {
			case EngineEvent.Topic.SHUTDOWN:
				log.info("Detected wiki engine shutdown");
				handleShutdown();
				break;
			case PageEvent.Topic.DELETED:
				String pageId = (String) event.getProperty(PageEvent.PROPERTY_PAGE_ID);
				m_storage.remove(pageId);
				m_counters.remove(pageId);
				break;
			}
		}

		/**
		 * Подсчитывает количество посещений страниц, отображает счетчик страницы или выводит список страниц
		 * с их счетчиками.
		 *
		 * @param context контекст вики.
		 * @param params  параметры плагина.
		 * @return String Фрагмент вики-страницы.
		 * @throws PluginException Неверный параметр шаблона.
		 */
		public String execute(WikiContext context, Map<String, String> params) throws PluginException {
			WikiPage page = context.getPage();
			String result = "";

			if (page != null) {
				// Получить параметры.
				String pageId = page.getId();
				String count = params.get(PARAM_COUNT);
				String show = params.get(PARAM_SHOW);
				int entries = NumberUtils.toInt(params.get(PARAM_MAX_ENTRIES), Integer.MAX_VALUE);
				int max = NumberUtils.toInt(params.get(PARAM_MAX_COUNT), Integer.MAX_VALUE);
				int min = NumberUtils.toInt(params.get(PARAM_MIN_COUNT), Integer.MIN_VALUE);
				String sort = params.get(PARAM_SORT);
				String body = params.get(PluginManager.PARAM_BODY);
				Pattern[] exclude = compileGlobs(PARAM_EXCLUDE, params.get(PARAM_EXCLUDE));
				Pattern[] include = compileGlobs(PARAM_INCLUDE, params.get(PARAM_INCLUDE));
				PatternMatcher matcher = new Perl5Matcher();
				// :FVK: была "оптимизация":
				// PatternMatcher matcher = (null != exclude || null != include || null != refer) ?
				// new Perl5Matcher() : null;
				boolean increment = false;

				// инкрементировать счетчик?
				if (STR_YES.equals(count)) {
					increment = true;
				} else {
					count = null;
				}

				// счетчик инкрементировать по умолчанию?
				if ((show == null || STR_NONE.equals(show)) && count == null) {
					increment = true;
				}

				synchronized (this) {
					Counter counter = m_counters.get(pageId);

					// считать только в режиме просмотра, синхронизировать значения хранилища.
					if (increment && WikiContext.PAGE_VIEW.equalsIgnoreCase(context.getRequestContext())) {
						if (counter == null) {
							counter = new Counter();
							m_counters.put(pageId, counter);
						}
						counter.increment();
						m_storage.setProperty(pageId, counter.toString());
						m_dirty = true;
					}

					if (show == null || STR_NONE.equals(show)) {
						// нечего показывать.

					} else if (STR_COUNT.equals(show)) {
						// показать счетчик страницы.
						if (counter == null) {
							counter = new Counter();
							m_counters.put(pageId, counter);
							m_storage.setProperty(pageId, counter.toString());
							m_dirty = true;
						}
						result = counter.toString();

					} else if (body != null && 0 < body.length() && STR_LIST.equals(show)) {
						// показать список счетчиков.
						String header = "";
						String line = body;
						String footer = "";
						int start = body.indexOf(STR_SEPARATOR);

						// разделить body на /header, line, footer/ с помощью разделителя ----
						if (0 < start) {
							header = body.substring(0, start);
							start = skipWhitespace(start + STR_SEPARATOR.length(), body);
							int end = body.indexOf(STR_SEPARATOR, start);
							if (start >= end) {
								line = body.substring(start);
							} else {
								line = body.substring(start, end);
								end = skipWhitespace(end + STR_SEPARATOR.length(), body);
								footer = body.substring(end);
							}
						}

						// сортировать по имени или количеству?
						Map<String, Counter> sorted = m_counters;
						if (STR_COUNT.equals(sort)) {
							sorted = new TreeMap<>(m_compareCountDescending);
							sorted.putAll(m_counters);
						}

						// создать буфер сообщений со списком в вики-разметке
						StringBuffer buf = new StringBuffer(header);
						MessageFormat fmt = new MessageFormat(line);
						Object[] args = new Object[] { id2NamePage.getName(pageId), "", "" };
						Iterator<Entry<String, Counter>> iter = sorted.entrySet().iterator();

						while (0 < entries && iter.hasNext()) {
							Entry<String, Counter> entry = iter.next();
							String id = entry.getKey();
							String name = id2NamePage.getName(id);

							// проверить минимальное/максимальное количество
							int value = entry.getValue().getValue();
							boolean use = min <= value && value <= max;

							// мы указали, какие страницы включать?
							if (use && include != null) {
								use = false;

								for (int n = 0; !use && n < include.length; n++) {
									use = matcher.matches(name, include[n]);
								}
							}

							// мы указали, какие страницы исключать?
							if (use && null != exclude) {
								for (int n = 0; use && n < exclude.length; n++) {
									use &= !matcher.matches(name, exclude[n]);
								}
							}

							if (use) {
								args[1] = name;
								args[2] = entry.getValue();

								fmt.format(args, buf, null);

								entries--;
							}
						}
						buf.append(footer);

						// let the engine render the list
						result = renderingManager.textToHTML(buf.toString());
					}
				}
			}
			return result;
		}

		/**
		 * Компилирует параметр регулярного выражения.
		 *
		 * @param name  Имя параметра.
		 * @param value Значение параметра.
		 * @return Pattern[] Скомпилированные шаблоны или <code>null</code>.
		 * @throws PluginException При неправильных шаблонах.
		 */
		private Pattern[] compileGlobs(String name, String value) throws PluginException {
			Pattern[] result = null;
			if (value != null && 0 < value.length() && !STR_GLOBSTAR.equals(value)) {
				try {
					PatternCompiler pc = new GlobCompiler();
					String[] ptrns = StringUtils.split(value, STR_COMMA);
					result = new Pattern[ptrns.length];

					for (int n = 0; n < ptrns.length; n++) {
						result[n] = pc.compile(ptrns[n]);
					}
				} catch (MalformedPatternException e) {
					throw new PluginException("Parameter " + name + " has a malformed pattern: " + e.getMessage());
				}
			}

			return result;
		}

		/**
		 * Настраивает смещение пропуска пробелов.
		 *
		 * @param offset Значение смещения, подлежащего настройке.
		 * @param value  Строка, в которой указаны точки смещения.
		 * @return int Скорректировано по значению смещение.
		 */
		private int skipWhitespace(int offset, String value) {
			while (Character.isWhitespace(value.charAt(offset))) {
				offset++;
			}
			return offset;
		}

		/**
		 * Возвращает количество посещений страницы.
		 *
		 * @return int Количество посещений страницы для данного ключа.
		 * @param key ключ для Counter.
		 */
		protected int getCount(Object key) {
			return m_counters.get(key).getValue();
		}

		/**
		 * Загружает счетчики просмотров страниц из файла.
		 */
		private void loadCounters() {
			if (m_counters != null && m_storage != null) {
				log.info("Loading counters.");
				synchronized (this) {
					try (InputStream fis = new FileInputStream(new File(m_workDir, COUNTER_PAGE))) {
						m_storage.load(fis);
					} catch (IOException ioe) {
						log.error("Can't load page counter store: " + ioe.getMessage() + " , will create a new one!");
					}

					// Copy the collection into a sorted map
					for (Entry<?, ?> entry : m_storage.entrySet()) {
						m_counters.put((String) entry.getKey(), new Counter((String) entry.getValue()));
					}

					log.info("Loaded " + m_counters.size() + " counter values.");
				}
			}
		}

		/**
		 * Сохраняет счетчики просмотров страниц в файл.
		 */
		protected void storeCounters() {
			if (m_counters != null && m_storage != null && m_dirty) {
				log.info("Storing " + m_counters.size() + " counter values.");
				synchronized (this) {
					// Write out the collection of counters
					try (OutputStream fos = new FileOutputStream(new File(m_workDir, COUNTER_PAGE))) {
						m_storage.store(fos, "\n# The number of times each page has been viewed.\n# Do not modify.\n");
						fos.flush();

						m_dirty = false;
					} catch (IOException ioe) {
						log.error("Couldn't store counters values: " + ioe.getMessage());
					}
				}
			}
		}

		/**
		 * Является ли заданный актор потока все еще актуальным?
		 *
		 * @param actor - актор потока, который может быть текущим фоновым потоком.
		 * @return boolean <code>true</code> если поток все еще является текущим фоновым потоком.
		 */
		synchronized boolean isRunning(Actor actor) {
			return m_initialized && actor == m_pageCounterSaveActor;
		}
	}

	/** Класс описывает Счетчик посещений страницы. */
	private static class Counter {

		/** Значение счетчика. */
		private int m_count = 0;

		/**
		 * Создает новый счетчик.
		 */
		public Counter() {
		}

		/**
		 * Создает и инициализирует новый счетчик.
		 *
		 * @param value значение счетчика.
		 */
		public Counter(String value) {
			setValue(value);
		}

		/**
		 * Приращение счетчика.
		 */
		public void increment() {
			m_count++;
		}

		/**
		 * Возвращает значение счетчика.
		 *
		 * @return int
		 */
		public int getValue() {
			return m_count;
		}

		/**
		 * Устанавливает значение счетчика.
		 *
		 * @param value Строковое представление счетчика.
		 */
		public void setValue(String value) {
			m_count = NumberUtils.toInt(value);
		}

		/**
		 * @return String Строковое представление счетчика.
		 */
		@Override
		public String toString() {
			return String.valueOf(m_count);
		}
	}

}
